Skip to content

fix(orchestrator): stop the probe PR resolver re-walking the whole PR tree - #377

Merged
miyaontherelay merged 2 commits into
mainfrom
fix/probe-pr-mount-walk-0825
Aug 25, 2026
Merged

fix(orchestrator): stop the probe PR resolver re-walking the whole PR tree#377
miyaontherelay merged 2 commits into
mainfrom
fix/probe-pr-mount-walk-0825

Conversation

@miyaontherelay

Copy link
Copy Markdown
Contributor

A sweep went silent for 11m53s and then reported stalled (inFlightMs 727833, missedPasses 12, ratio 0.985) while the process stayed healthy — liveHeartbeat logging throughout, log ring lossless (droppedBytes 0), consecutiveFailures 0. Nothing was failing. The sweep was slow by construction, inside resolveIssuePrFromMount, which answers "which PR belongs to this issue" by reading every mounted PR record one at a time.

10:25:30.117Z listTree /cloud/pulls/by-id/ -> 1156 paths   <-- LAST LINE THE SWEEP EMITS
10:25:30 -> 10:37:23Z  silence
10:37:23Z     stalled

Relationship to #374

Complementary, not redundant.

#374 bounds the whole sweep stops a wedge burning unbounded wall-clock — the seatbelt
This PR (A1–A4) removes the reason the walk is expensive — the brakes
relayfile-adapters#271 removes the walk entirely by putting headRef in the pull index row

Rebased onto 00d51ad; no conflicts. sweep-budget.test.ts (all 810 lines of #374's suite) passes alongside this change. No instrumentation collision: #374's budget?.assertNotExpired() and the ready-issue read-progress idiom already coexist in the same loop (factory.ts:3652-3658); A4 mirrors the logging half of that loop, so the two mechanisms sit side by side exactly as they already do upstream.

The four defects

1. #resolveIssuePr could not scope to a repository

resolveIssuePrFromMount has always honoured opts.repo, but #resolveIssuePr's own opts had no repo field, so it forwarded undefined and walked EVERY configured repository — 21 in the live workspace — to find a PR that can only live in one. None of its four call sites nor the probePrResolver port could scope it.

Adds repo?: string and threads the routing answer through all of them via a new #probeRepoForIssue, which reuses dependencyRepoForIssue — the same helper #dependencyIsTerminalOrMerged already uses for its own probe. Ambiguous routing (multi-route decision, unlabelled issue) still walks unscoped: narrowing on a guess would silently miss a PR that is really there.

Changed: #probeRepoForIssue (new, factory.ts:3021), opts field at 3033, call sites at 2991, 3002, 4750, 19033, port at 1204.

2. The mount hit never populated the cache it reads

#resolveIssuePr reads #probePrResolvedCache at the top, then runs the mount walk first — the common hit — and returned without ever writing it. The cache had a reader and no writer on the hot path, so the whole walk repeated per caller, per sweep, forever.

Now cached on the same terms as the gh branch below it: same key, same TTL, same draft exclusion (the reason to keep a draft uncached is a property of the PR, not of which resolver observed it).

Changed: factory.ts:3053-3065.

3. The walk read most pull requests twice

githubPullRoots returns two roots for one repository — the nested <owner>/<repo>/pulls/ layout and the flat <owner>__<repo>/pulls/by-id/ alias — and unions them into a Set keyed by path string, so one PR under two spellings counted twice. That is the 2877 + 1156 = 4033 in the repro for roughly 1156 actual pull requests.

Deduped on the identity the path already carries, via githubPullPathParts — no mount read, so the dedupe is free. Insertion order is preserved and the first spelling wins, which is the candidate the existing stable sort already kept when two spellings of one PR tied, so the winner does not move. Paths carrying no PR identity (_index.json, per-PR comments/*.json) stay in the walk and are read exactly as before rather than filtered on a guess.

Changed: factory.ts:20643-20674.

4. The read loop was invisible

listTree is wrapped by #listRelayfileTree — named, timed, logged. The readFile per candidate ran inside a bare try/catch that swallows failures into undefined, with no logger, no counter and no progress line. That is why twelve minutes of real work was indistinguishable from a hung process for three prior investigation layers; the observability gap is a first-class defect here, not a nice-to-have.

Adds progress reporting on #logTimedProgress — the same cadence helper the ready-issue read loop uses — plus a probePrMountReads counter.

Changed: #probeMountWalkProgress (new, factory.ts:5206), wired at 3050 and 9280.

Two more found while fixing the above

The cache invalidation was already broken. On completion it deleted only the bare issue key, never the :open / :legacy suffixed variants #resolveIssuePr actually writes — so every openOnly probe (#openPrForIssue, #openCompletionPr, i.e. the completion path) was never invalidated at all. Harmless while the mount branch wrote nothing; a live correctness bug the moment it does. Now clears the whole key family (factory.ts:16024-16041).

#dependencyIsTerminalOrMerged had no cache at all — and it is the path that produced the repro. It calls resolveIssuePrFromMount directly (it must not fall back to gh), so it never saw #resolveIssuePr's cache, and #terminalDependencyIdentities memoises only the TRUE answer. A dependency that is not merged was re-walked in full for every issue declaring it, on every sweep. Adds a sweep-scoped memo for the negative answer, cleared beside the terminal set so a PR merging between sweeps is still observed (factory.ts:812, 3605, 9273).

This corrects the framing in the original diagnosis. The verified repro runs through #dependencyIsTerminalOrMerged, which already passed repo and never touched #probePrResolvedCache. So defects 1 and 2 — real as they are — do not fix the observed 11m53s stall; they fix the aggravated 21-repo variant. What fixes the repro is defect 3, the dependency memo, and defect 4 making it visible.

What is NOT fixed, deliberately

No early break on a maximal-score match. The sort is b.score - a.score || b.prNumber - a.prNumber, so a score-30 hit does not win until every higher-numbered candidate is known to score no better; and readProbePrCandidate takes pr.number from the payload rather than the path, so path order does not prove PR-number order. Semantics could not be shown preserved, so per the brief the dedupe ships and the early break does not.

No index fast path. pulls/_index.json rows carry no headRef, and the primary match (score 30) is a branch match — so the index cannot exclude any PR from consideration, and a title hit (score 20) must never be returned as the answer while an unread branch match could outrank it. Note this is a stronger objection than "the index is the wrong shape": it holds even on a well-formed index. Instead the resolver now logs why it fell back (index-absent / index-shape-unrecognised / index-without-head-ref / index-usable), so the day adapters#271 lands shows up in the logs rather than passing unnoticed.

No bounded concurrency in the read loop. It would fix wall-clock without changing read count, but it is a behavioural change to the hot path that nobody asked for; recommended as a follow-up.

On _index.json shapes — evidence

Verified directly against the relayfile-adapters checkout at e6edb075:

  • index-emitter.ts:112-134 (buildRepoIssuesIndexFile / buildRepoPullsIndexFile) writes a bare top-level array at the canonical nested path — the shape Factory's reader accepts. Confirmed by bulk-ingest.test.ts:490, which asserts pulls/_index.json parses to [{ id, title, updated, number, state, merged, mergedAt }].
  • lazy.ts:161,179 (eager backfill) writes { issues: [...] } / { pulls: [...] } — object-wrapped — to the same canonical path.
  • bulk-writer.ts:902 writes a directory manifest at the flat alias path; it contains no records.

Answering the question on #githubIssuePathsFromIndex (factory.ts:8759): it is conditionally, not universally, falling back. On mounts last written by the incremental index emitter the shape is the bare array it accepts and labels is present on issue rows (added for exactly this gate, index-emitter.ts:20-23), so it works. On eager-backfilled mounts it gets the { issues: [...] } object, fails Array.isArray, and silently falls back to the tree walk. Same canonical path, two writers, so which behaviour you get depends on which writer touched it last. Not fixed here — separate lane.

Red-then-green evidence

Every test pinned on read count, not wall clock, using FakeMountClient.reads. All measured on the rebased tree (378bd95 on 00d51ad), each fix reverted independently:

Test Fix reverted RED GREEN
scopes the probe PR mount walk to the issue repository… #probeRepoForIssue returns undefined expected [ …(13) ] to have a length of 5 but got 13 5
reads each probe PR record once when the same PR is mounted under both pull roots dedupe key disabled expected [ …(2) ] to have a length of 1 but got 2 1
serves a repeated probe PR resolution for one issue from cache… mount-hit cache write disabled expected [ …(4) ] to have a length of 2 but got 4 2

The first is the O(N) vs O(N×R) assertion: 4 PRs in each of 3 configured repos, issue routed to one — 5 reads scoped, 13 unscoped.

Test results

$ ./node_modules/.bin/vitest run src/orchestrator/factory.test.ts
 Test Files  1 passed (1)
      Tests  621 passed (621)
   Duration  311.76s

$ ./node_modules/.bin/vitest run src/orchestrator/sweep-budget.test.ts \
    src/orchestrator/sweep-counters.test.ts src/orchestrator/dispatch-failure-reasons.test.ts \
    src/orchestrator/health-projection-guard.test.ts src/writeback/writeback.test.ts \
    src/node/factory-node.test.ts
 Test Files  6 passed (6)
      Tests  143 passed (143)

Environment caveat, stated plainly: the sandbox has no access to the private npm registry, so npm ci could not install this branch's dependency tree. Tests ran against an overlay of the nearest locally-available packages. Two consequences, both verified rather than assumed:

  • src/node/factory-persona-card.test.ts fails 7/7 — it needs @relaycast/a2a@^6.2.0 and only 1.1.7 was available locally. Confirmed pre-existing: identical 7 failures with my changes stashed on pristine main.
  • tsc --noEmit reports 225 errors repo-wide from the same version skew (e.g. @relayfile/sdk missing exports, Promise.withResolvers needing a newer lib). Zero of them are in src/orchestrator/factory.ts.

Scope

Touches only src/orchestrator/factory.ts and src/orchestrator/factory.test.ts, per the gate.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fbcb5797-f855-4e91-9137-57f58a8fb00e


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head 378bd95b202379d7ced87dae266e087401c8f12f.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/orchestrator/factory.ts Outdated
Comment thread src/orchestrator/factory.ts
… tree

A sweep went silent for 11m53s and then reported stalled (inFlightMs 727833,
missedPasses 12) while the process stayed healthy — liveHeartbeat logging
throughout, log ring lossless, consecutiveFailures 0. Nothing was failing. The
sweep was slow by construction, in `resolveIssuePrFromMount`, which answers
"which PR belongs to this issue" by reading every mounted PR record one at a
time.

Four defects, all in the same walk.

1. `#resolveIssuePr` could not scope to a repository. `resolveIssuePrFromMount`
   has always honoured `opts.repo`, but `#resolveIssuePr`'s own opts had no
   `repo` field, so it forwarded `undefined` and walked EVERY configured
   repository — 21 in the live workspace — to find a PR that can only live in
   one. None of its four call sites nor the `probePrResolver` port could scope
   it. Adds `repo?: string` and threads the routing answer through all of them
   via `#probeRepoForIssue`, which reuses `dependencyRepoForIssue` — the same
   helper `#dependencyIsTerminalOrMerged` already uses for its own probe.
   Ambiguous routing still walks unscoped: narrowing on a guess would miss a PR
   that is really there.

2. The mount hit never populated the cache it reads. `#resolveIssuePr` reads
   `#probePrResolvedCache` at the top, then runs the mount walk FIRST — the
   common hit — and returned without ever writing it. The cache had a reader
   and no writer on the hot path, so the whole walk repeated per caller, per
   sweep, forever. Now cached on the same terms as the gh branch: same key,
   same TTL, same draft exclusion.

3. The walk read most pull requests twice. `githubPullRoots` returns two roots
   for one repository — the nested `<owner>/<repo>/pulls/` layout and the flat
   `<owner>__<repo>/pulls/by-id/` alias — and unions them into a Set keyed by
   PATH STRING, so one PR under two spellings counted twice. Deduped on the
   identity the path already carries via `githubPullPathParts`, which costs no
   read. Paths that carry no PR identity (`_index.json`, per-PR `comments/*`)
   are left in the walk and still read exactly as before.

4. The read loop was invisible. `listTree` is wrapped by `#listRelayfileTree` —
   named, timed, logged. The `readFile` per candidate ran in a bare try/catch
   that swallows failures into `undefined`, with no logger, counter or progress
   line, which is why twelve minutes of real work was indistinguishable from a
   hung process for three prior investigation layers. Adds progress reporting on
   the same cadence helper the ready-issue read loop uses, plus a
   `probePrMountReads` counter.

Also, two things found while fixing the above:

- The cache invalidation on completion deleted only the BARE issue key, never
  the `:open` / `:legacy` suffixed variants `#resolveIssuePr` actually writes.
  Every `openOnly` probe — i.e. the completion path — was never invalidated.
  Harmless while the mount branch wrote nothing; a live correctness bug the
  moment it does. Now clears the whole key family.

- `#dependencyIsTerminalOrMerged` does not go through `#resolveIssuePr` (it must
  not fall back to gh), so it saw no cache at all, and
  `#terminalDependencyIdentities` memoises only the TRUE answer. A dependency
  that is not merged was re-walked in full for every issue declaring it, on
  every sweep. Adds a sweep-scoped memo for the negative answer, cleared beside
  the terminal set so a PR merging between sweeps is still observed. This is the
  path that produced the reported repro.

Relationship to #374, which bounds the whole sweep: complementary, not
redundant. #374 stops a wedge burning unbounded wall-clock — the seatbelt. This
removes the reason the walk is expensive — the brakes. relayfile-adapters#271
would remove the walk entirely by putting `headRef` in the pull index row.

NOT FIXED, deliberately: no early break on a maximal-score match. The sort is
`b.score - a.score || b.prNumber - a.prNumber`, so a score-30 hit does not win
until every higher-numbered candidate is known to score no better, and
`readProbePrCandidate` takes `pr.number` from the payload rather than the path,
so path order does not prove PR-number order. Semantics could not be shown
preserved, so per the brief the dedupe ships and the early break does not.

No index fast path either: `pulls/_index.json` rows carry no `headRef`, and the
primary match (score 30) is a branch match, so the index cannot exclude any PR
from consideration and a title hit (score 20) must never be returned while an
unread branch match could outrank it. Instead the resolver now logs WHY it fell
back — index absent, shape unrecognised, or present without `headRef` — so the
day adapters#271 lands shows up in the logs rather than passing unnoticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7

Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7

Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7
@miyaontherelay
miyaontherelay force-pushed the fix/probe-pr-mount-walk-0825 branch from 378bd95 to a4608f6 Compare August 25, 2026 18:48
@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head a4608f6c7901111540108149c3b897e4a2c544b2.

…probe cache by repo

Two #377 review findings from cubic-dev-ai. Both were correct; the first was a
correctness regression this PR introduced.

P1 — `#probeRepoForIssue` scoped every probe to `repos.default` whenever the
issue carried no label or project evidence. Routing precedence is byLabel,
byProject, keywordRules, default, and `dependencyRepoForIssue` can see neither
the triage decision nor `keywordRules` — those match on issue TEXT through
triage. So for a keyword-routed issue it answered `repos.default` while dispatch
had opened the PR in the keyword-selected repository. The probe then walked one
repository, confidently, and found nothing: "no PR" reported for an issue that
has one, and the completion path acts on that answer. That is strictly worse
than the slow walk this PR set out to remove, and it contradicted the docstring
sitting two lines above it.

Threading the real triage decision was not reachable: all five probe call sites
take only a `LinearIssue`, and at completion time the decision no longer exists.
So the fallback is now the unscoped walk — `dependencyRepoForIssue` grows an
opt-out `allowDefault` (default unchanged for its four other callers) and the
probe wrapper passes `false`. Ambiguity widens the walk; it never narrows it.
The dedupe and cache in this same PR already blunt the cost.

P2 — `repo` narrows which pull requests a resolution can even see, so it is a
resolution dimension, but it was absent from the cache and gh-backoff keys. A
route change could therefore serve the previous repository's PR, and the
completion path probes and CLOSES what it is handed.

Adding that dimension exposed a second, pre-existing defect: the completion
sweep wrote its draft-PR backoff under a BARE issue state key while
`#completionPrForIssue` read the suffixed one. They agreed only by accident, and
the new suffix broke that accident — caught by `gh PR fallback skips draft PRs
and backs off repeated unresolved lookups`, which went red. Both maps now build
their key through one shared `#probePrCacheKey`, so the two writers cannot
drift again. Every dimension stays a trailing `:`-prefixed segment, so the
completion invalidation added in this PR keeps clearing the whole key family.

NOT SHIPPED: a test for P2's stale cross-repo hit. Probe scope is a pure
function of (issue, config) at all five call sites, and the completion path
clears the whole key family, so no public path varies the scope for one issue
inside the TTL. Every way to force it needed a production test hook, and this
file has no precedent for reaching into internals. P2 ships as defensive
correctness plus the real backoff-key fix its test DID catch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7
@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head 2d573317b50072063b432b6f5450b216e2562c8d.

@miyaontherelay
miyaontherelay merged commit effd4c7 into main Aug 25, 2026
8 checks passed
@miyaontherelay
miyaontherelay deleted the fix/probe-pr-mount-walk-0825 branch August 25, 2026 20:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant